Javascript是單執行緒(Single thread)的語言,也就是說,程式的執行是依照程式碼的順序按步就班的執行。單執行緒最大的困擾便是會產生阻塞的情形,也就是當部分的程式碼執行時間過久,而這些程式碼執行的結果並不影響後面程式的執行,而後面的程式碼依然會被延後執行,而造成程式卡頓的情形。為了解決程式阻塞的問題,Javascript引擎利用事件循環(Event Loop)、事件佇列(Event Queue)和呼叫堆疊(Call Stack)的機制設計了非同步執行的模式。因此,當你有部分的程式執行可以獨立進行時,便可啟動非同步執行;Javascript會將同步執行的部分執行完畢後,再依序啟動非同步執行。
在ES6之後,非同步執行的啟動方法是建構一個Promise物件,它的建構子需要提供一個有兩個輸入參數的函數,這二個參數通常取名為resolve(第一個參數)和reject(第二個參數),二個參數名稱可自行命名,第一個參數resolve函數負責回傳成功的結果,第二個參數reject函數負責回傳失敗的結果。我們傳給建構子的函數會同步執行,但是resolve和reject兩個函數只會執行一個。每個Promise會有PromiseState和PromiseResult兩個屬性,PromiseState的值為pending、fulfilled和rejected三者之一,如果你的建構函數尚未執行resolve或reject,PromiseState的值便是pending,PromiseResult的值是undefined;如果執行了resolve,PromiseState的值便是fulfilled,PromiseResult的值是resolve傳的值;如果執行了reject,PromiseState的值便是rejected,PromiseResult的值是reject傳送的值。
我們先看一個Promise的建構
const p = new Promise((resolve, reject) => {
console.log('Promise begins')
resolve(1)
reject(2)
console.log('Promise end')
})
console.log('Sync...');
執行結果如下:
Promise begins
Promise end
Sync...
注意,根據上面程式的測試,建構函數裏面的的程式是同步的,即便執行了resolve或reject之後,仍然會執行,而且和其它同步程式的部分依序執行。
Promise還有resolve和reject兩個靜態方法,Promise.resolve(3)等同於new Promise((resolve, reject) => resolve(3)),而Promise.reject(3)等同於new Promise((resolve, reject) => reject(3))
我們建構出來的Promise物件p有then、catch和finally三個方法(函數),這三個方法都會回傳一個新的Promise物件,所以可以用鏈接模式撰寫程式。我們都需要提供一個呼函數(callback function)給它們當參數,下面我們詳細說明這三個方法的邏輯。
如果在三個方法上拋出錯誤,則會回傳Promise.reject(e.message)
依照這個邏輯,你可以隨意鏈結這三個方法。
如果覺得上面的實作細節太過複雜,你只要知道這樣設計的目的是讓then負責fulfilled,catch負責rejected,兩個方法只會選一條路走即可。
const p: Promise<number> = new Promise((resolve, reject) => {
console.log('Promise begins');
resolve(1);
reject(2);
console.log('Promise end');
});
const p1 = p
.then((v) => {
console.log(v);
// throw Error('Error in then');
return Promise.reject(2);
})
.catch((e) => {
console.log(e);
// throw Error('Error in catch');
return 3;
})
.finally(() => {
// throw Error('Error in finally');
console.log('finally');
})
.then((v) => {
console.log(`Success ${v}`);
})
.catch((e) => {
console.log(`Failure ${e}`);
})
.finally(() => console.log('finally again'));
console.log('Sync...');
console.dir(p1);
你可以調整p裏面resolve和reject的順序,或是依條件回傳,來決定回傳的狀態
const p = (n: number): Promise<number> => new Promise((resolve, reject) => {
console.log('Promise begins');
n > 0 ? resolve(n) : reject(n);
console.log('Promise end');
});
如此便能控制p(n)是resolve還是reject的Promise,你可以多做實驗以確實了解then、catch和finally的行為。實務上,我們會有很多個then用來鏈結處理回傳有的值,而用一個catch則做為錯誤處理,一個fnally處理必然要做的事,上面的程式純綷作為測試了解這三個方法的行為。
我們的函數式程式設計風格並不太使用鍵接模式,我們將採用另一種Javascript提供的async/await語法,上面的p我們可以改寫成
const p = async (n: number) => {
console.log('Promise begins');
console.log('Promise end');
if (n > 0) return n;
throw n;
};
async語法中的return n等同於Promise建構函數的resolve(n),而async語法中的throw n等同於Promise建構函數的reject(n),你如果看一下編輯器上p的型別簽名,兩種寫法都是const p: (n: number) => Promise<number>
。這種async語法建立的p一樣可以使用then,catch和finally的鏈接語法,但是為了風格一致,我們會更傾向於使用aync/awain的寫法。
const chainP = async () => {
try {
const v = await p0(3);
console.log(v);
} catch (e) {
console.log(e);
} finally {
console.log('finally');
}
};
chainP();
最後要記得執行chainP,才能得到最後的結果。
setTimeout是在Javascript中的一個計時器函數,可以要求Javascript引擎在一段時間後執行一個回呼函數(callback function),一樣是利用事件迴圈、事件佇列機制的非同步執行。它會回傳一個計時器ID,用這個計時器ID作為clearTimeout的參數便可取消這個回呼函數的執行,因為setTimeout回傳的事計時器ID,並不是Promise,所以它不能使用then、catch和finally三個方法,也不能使用async/await語法,我們可以將setTimeout用Promise包裝,成為延遲時間的函數,讓我們看看setTimeout函數的用法。
const timer = setTimeout(callback, delay) // dealy ms後執行callback
clearTimeout(timer) // 停止callback的執行
import { Task } from 'fp-ts/Task'
type Delay = (ms: number) => Task<void>;
export const delay: Delay = (ms) => async () =>
new Promise((resolve) => setTimeout(resolve, ms));
這個delay函數便可以在async/await中使用。
const delay3seconds = async () => {
await delay(3000)()
}
delay3seconds()
接下來要進入我們fp-ts/Task模組的介紹。當我們了解了Promise和相關async/await語法,我們便可以很快的了解Task的型別簽名type Task<A> = <A> () => Promise<A>
,Task<A>
和IO<A>
非常類似,IO<A>
是同步執行的型別A計算,Task則是非同步執行的型別A計算,兩者都是必須呼叫才能得到計算後的值,兩者都可通稱為LazyArg,因此函數合成的過程中能保持純函數的性質。當然Task<A>
也是一個Monad Functor,因此模組中也有map、ap、flatMap函數讓我們轉換函數讓以便函數的合成能夠過順利。我們看看下面的程式範例:
在fp-ts型別簽名中的
LazyArg<A>
等同於IO<A>
,LazyArg<Promise<A>>
等同於Task<A>
type Config = { logPrefix: string };
const loadConfigT: Task<Config> = async () => {
await delay(2000)(); // delay(20)必須執行await才有意義
return { logPrefix: '[Task Main]' };
};
type MakeLogger = (config: Config) => IO<void>;
const makeLogger: MakeLogger = (config) =>
log(config.logPrefix + 'Hello world!');
const mainTask =
pipe(
loadConfigT,
map(makeLogger), // makeLogger :: Config -> IO<void>
);
mainTask().then(io => io());
因為makeLogger是Config -> IO<void>
,因此map(makeLogger)的輸出型別將是Task<IO<void>>
,這也是mainTask的輸出型別,如果執行mainTask()將會得到一個Promise,必須使用then方法取得Promise中resolve的值,而makeLoger回傳的是一個IO<void>
,因此then裏面便是將io取出,然後執行這個io[io()],如果不想使用鏈接模式,我們要想辦法把Task<IO<void>
型別變成Task<Task<void>>
再flatten。
Task模組有一個fromIO的函數,它的型別簽名為fromIO :: IO<void> -> Task<void>
,我們可以用flatMat(fromIO)便可得到Task的輸出型別,最後只要執行mainTask()即可,不必使用then方法,這樣的寫法更符合我們FP的風格。以下是mainTask最終的呈現:
const mainTask =
pipe(
loadConfigT,
map(makeLogger), // makeLogger :: Config -> IO<void>
// flatMap((loggerIO) => of(loggerIO()))
flatMap(fromIO) // fromIO :: IO<void> -> Task<void>
);
mainTask()
ap的使用風格也是和其它Applicative Functor一樣的模式。
const taskNumber = (t: number) => (x: number) => async () => {
await delay(t)();
return x;
};
const add = (x: number) => (y: number) => x + y
const logNumber = (n: number) => log(`Sum is ${n}`);
const taskAdd = pipe(
of(add),
ap(taskNumber(3000)(2)),
ap(taskNumber(2000)(3)),
map(logNumber),
flatMap(fromIO)
);
taskAdd();
其實我們只是將一些async/await風格轉變成函數式風格,使用「接管」(pipe)將各個資料串接。
我們可以用遞迴和Task寫一個forever的函數取代無窮迴圈while(true){}
import { Task, chain } from 'fp-ts/Task';
const forever = (ma: Task<void>): Task<void> =>
pipe(
ma,
chain(() => forever(ma)) // recursion
)
用遞迴設計函數時,當遞迴太深時會有堆疊不足(Stack blow)的情形,但是因為Task是非同步且延遲執行,遞迴建立等到非同步執行結果回傳才會發生,因此不會有堆疊不足(Stack blow)的狀況發生;如果使用IO實作forerver則會有堆疊不足(Stack blow)的危險。
Task只能處理Promise中必然resolve的情境,而沒有考慮reject的可能性。如果我們原來的的loadConfigT是下面這種情形,則我們的mainTask可能會合成失敗,造成程式拋出錯誤而中斷結束。
const loadConfigT: Task<Config> = async () => {
await delay(2000)(); // delay(20)必須執行await才有意義
const success = Math.random() > 0.2; // 70% chance to succeed
if (!success) throw new Error('Failed to load config');
return { logPrefix: '[Task Main]' };
};
要處理Promise中reject的部分,我們需要使用TaskEither<E, A>
這個型別建構子,它比Task多了一個型別參數E,通常是我們的錯誤型別。而TaskEither最常用的建構函數是tryCatch,它需要兩個函數參數,第一個參數型別是Task<A>
便是我們例子中的loadConfig;第二個函數的型別簽名則是(reason: unknown) => E,用途是將第一個函數拋出的錯誤轉換成型別E。下面是loadConfigT改寫成loadConfigTE並將函數合成的程式範例:
import * as TE from 'fp-ts/TaskEither'
const loadConfigTE: TE.TaskEither<Error, Config> = TE.tryCatch(
async () => {
await delay(4000)();
const success = Math.random() > 0.8; // 70% chance to succeed
if (!success) throw new Error('Failed to load config');
return { logPrefix: '[TaskEither Main]' };
},
(reason) => (reason instanceof Error ? reason : new Error(String(reason)))
);
const mainTaskEither = pipe(
loadConfigTE,
TE.map(makeLogger), // makeLogger :: Config -> IO<void>
TE.match( // TE
(e) => error('Error:' + e.message),
(v) => log('Logging done successfully')
),
flatMap(fromIO)
);
mainTaskEither()
TaskEither
& Task<Either>
TaskEither
為Task<Either>
的子集,在fp-ts中TaskEither
和Task<Either>
,可以自由轉換。
interface TaskEither<E, A> extends Task<Either<E, A>> {}
const taskOfEither: T.Task<E.Either<Error, string>> = T.of(E.right('Hello'))
const taskEither: TE.TaskEither<Error, string> = TE.right('Hello') ;
const fromTaskOfEither: TE.TaskEither<Error, string> = taskOfEither;
const fromTaskEither: T.Task<E.Either<Error, string>> = taskEither;
TaskEither
模組提供的API比較豐富,所以儘可能轉換TaskEither型別進行處理。
Promise是typescript處理非同步非常重要的機制,它的邏輯不像同步執行的程式這麼直覺,需要花一點時間才能完全了解,尤其是then、catch和finally的運作細節。如果不想不求甚解,可以從設計的目的著眼了解,其一是為了能夠讓鏈接模式持續,每一個方法必定回傳一個Promise;其二是then和catch只會選一條走;其三是finally一定會經過。
fp-ts把Promise封裝成為Task或TaskEither,應用了Funtor的概念抽象化後,讓Task的操作和錯誤的處理(Option、Either),或是同步的計算(IO)都沒有什麼區別,使用同一種函數名稱(像map、ap和flatMap)在不同的容器中進行資料的流轉。這樣的思維徹底展現抽象化的威力,即便是複雜的非同步工作,我們如果維持在形而上的抽象層工作,也和其它型別容器沒有差別,這也是函數式程式設計風格的靈魂。
今天的內容就到此結束,明天再見。